Skip to content

feat: AES-256-GCM message encryption with send/receive enforcement (#2643)#2644

Merged
jeremydmiller merged 21 commits intoJasperFx:mainfrom
BlackChepo:feature/Message_Encryption
May 1, 2026
Merged

feat: AES-256-GCM message encryption with send/receive enforcement (#2643)#2644
jeremydmiller merged 21 commits intoJasperFx:mainfrom
BlackChepo:feature/Message_Encryption

Conversation

@BlackChepo
Copy link
Copy Markdown
Contributor

Summary

Adds opt-in application-layer message encryption (AES-256-GCM) to Wolverine, with first-class send-side and receive-side configuration, a key-provider abstraction with
bounded caching, and end-to-end test coverage including a wire-level negative test.

Closes #2643.

Why

Wolverine currently relies on transport-level TLS for in-flight confidentiality and broker-level encryption at-rest. That is not enough for:

  • Compliance regimes (PCI-DSS, HIPAA, GDPR) that mandate body-level encryption beyond what the broker provides.
  • Hosted/shared brokers where the operator should not be able to read message contents from queue inspection or backups.
  • Selective protection of sensitive message types (PaymentDetails, MedicalRecord) while keeping the rest in plain JSON for debuggability.

Users currently roll their own envelope encryption on top of Wolverine, which is error-prone (nonce reuse, missing AEAD binding, unprotected MessageType enabling routing
attacks).

What changed

Public surface

opts.UseEncryption(IKeyProvider provider);                // global default
opts.RegisterEncryptionSerializer(IKeyProvider provider); // additional, opt-in
opts.Policies.ForMessagesOfType<T>().Encrypt();           // per-type
opts.PublishAllMessages().To(...).Encrypted();            // per-endpoint (send)
opts.ListenAtPort(...).RequireEncryption();               // per-listener (receive)

Cryptographic core

  • EncryptingMessageSerializer (Wolverine/Runtime/Serialization/Encryption/) wrapping any inner IMessageSerializer. AES-256-GCM with a fresh 12-byte random nonce per
    encryption and a 16-byte auth tag.
  • AAD binds MessageType + key-id + inner-content-type + a wlv-enc-v1 magic prefix into the auth tag, so tampering any of those three fails decryption (blocks
    cross-handler / cross-content-type routing attacks).
  • Dedicated content-type application/wolverine-encrypted+json.
  • Inner serializer (System.Text.Json / Newtonsoft / MessagePack / MemoryPack) is preserved and selected via the inner-content-type header on receive.

Key handling

  • IKeyProvider abstraction: string DefaultKeyId { get; } + ValueTask<byte[]> GetKeyAsync(string keyId, CancellationToken ct).
  • InMemoryKeyProvider for tests/samples — defensively copies keys in and out so caller mutation cannot corrupt subsequent encryptions.
  • CachingKeyProvider decorator: bounded LRU, thread-safe, single-flight per key-id, per-caller cancellation isolation (one cancelled caller never poisons a shared key
    fetch).

Receive-side enforcement

  • HandlerPipeline.RequiresEncryption rejects forged plaintext envelopes for marked types or marked listeners before any serializer runs. Routes them to the dead-letter
    queue with EncryptionPolicyViolationException.
  • Polymorphic: marking a base type / interface enforces encryption for every concrete subtype.

Exception hierarchy

  • MessageEncryptionException (abstract base, no KeyId).
    • EncryptionKeyNotFoundException (carries KeyId).
    • MessageDecryptionException (carries KeyId; tag mismatch or malformed body).
    • EncryptionPolicyViolationException (no key involved — policy-level).

Configuration safety

  • Double-UseEncryption and double-RegisterEncryptionSerializer throw at config time.
  • UseSystemTextJsonForSerialization / UseNewtonsoftForSerialization are order-insensitive — they only replace the default when its content-type is application/json, so
    calling them after UseEncryption is a no-op against the default.
  • Encrypted() / RequireEncryption() validated at startup, not at first message.

API hygiene

  • The receive-side enforcement state (RequiredEncryptedTypes, RequiredEncryptedListenerUris, IsEncryptionRequired) is internal so the guard cannot be silently
    disabled by host code mutating a public collection.
  • EncryptingMessageSerializer.WriteMessage(object) and ReadFromData(byte[]) (no-envelope overloads) throw InvalidOperationException rather than silently returning the
    inner serializer's plaintext under the encrypted content-type.

Tests

All new tests live in src/Testing/CoreTests/:

  • Acceptance/encryption_acceptance.cs — two-host round-trip, key-bytes mismatch → DLQ, unknown key-id → DLQ, per-type / per-listener / polymorphic-supertype guards,
    rolling-deploy unmarked type passes, wire-bytes TCP-MITM proxy test confirms the canary plaintext never appears on the wire, durable-persistence path test locks
    ciphertext-not-plaintext at the data-materialisation point.
  • Runtime/Serialization/Encryption/EncryptingMessageSerializerTests.cs — AAD binding, nonce uniqueness, tampering negatives (key-id / message-type / inner-content-type /
    ciphertext / tag), wrong-key-length wrapping, cross-flavor sender/receiver round-trips, no-envelope overload throws, missing-header defense-in-depth.
  • Runtime/Serialization/Encryption/CachingKeyProviderTests.cs — single-flight, per-caller cancellation isolation, bounded LRU, eviction race safety.
  • Runtime/Serialization/Encryption/InMemoryKeyProviderTests.cs — key-length validation, default-key-id presence, defensive copy.
  • Runtime/Serialization/Encryption/WolverineOptionsEncryptionTests.csUseEncryption swap, RegisterEncryptionSerializer coexistence, per-type / per-endpoint routing,
    double-call throws, listener-URI registration, no-endpoint pipeline still enforces per-type marker, JSON-after-encryption order-insensitivity.
  • Runtime/Serialization/Encryption/WolverineOptionsIsEncryptionRequiredTests.cs — exact match, polymorphic match, multi-marker, cache memoization.
  • Runtime/Serialization/Encryption/exception_hierarchy.cs — base/subclass relationships, message content does not leak plaintext.

All 87 encryption tests pass on net8.0, net9.0 and net10.0, including under DOTNET_ENVIRONMENT=Development (scope validation enabled).

Docs and sample

  • New Vitepress guide page docs/guide/runtime/encryption.md covering quickstart, IKeyProvider, selective encryption, key rotation, integrity guarantees and the
    header-leak caveat, error handling, and what's not included.
  • Runnable src/Samples/EncryptionDemo/ showing per-type Encrypt<T>() and listener-side RequireEncryption().

Out of scope

  • AES-CBC (GCM only — authenticated encryption by construction).
  • Header / metadata encryption — MessageType is integrity-protected via AAD but not confidential. The doc page tells operators to put sensitive values in the body, not
    in headers.
  • Asymmetric / per-recipient encryption.
  • First-party Cloud-KMS adapter packages — write a thin IKeyProvider over your KMS; adapter packages may ship later.
  • Replay protection — use existing DeduplicationId / MessageIdentity if needed.

Risks / open questions

  • IAsyncMessageSerializer interface has no CancellationToken, so key-provider calls during WriteAsync / ReadFromDataAsync use CancellationToken.None. A slow KMS
    fetch cannot be cancelled by host shutdown — providers should apply their own internal timeouts. Documented in the class XML doc.
  • AES-GCM behavior is consistent across net8 / net9 / net10 — verified.

…tion

Introduces the abstractions and pure key-management types for the
upcoming Wolverine application-layer encryption feature:
IKeyProvider with InMemoryKeyProvider and CachingKeyProvider
(TTL + single-flight dedup + evict-on-fault), the
MessageEncryptionException hierarchy, and the on-wire content-type
and header-name constants. EncryptingMessageSerializer follows in
a later commit.
Decorator over an inner IMessageSerializer that encrypts envelope
bodies with AES-256-GCM (12-byte nonce, 16-byte tag) and emits
content-type application/wolverine-encrypted+json. Receive-side
dispatches by content-type, decrypts, then unwraps to the inner
serializer. Sync surface bridges to the async path via
GetAwaiter().GetResult() to remain compatible with Wolverine's
sync sites in Envelope.Data and BatchedSender. Documents
IKeyProvider.GetKeyAsync as returning a borrowed reference.
…ration

WolverineOptions.UseEncryption(provider) wraps the current default
JSON serializer in an EncryptingMessageSerializer and makes it the
default; RegisterEncryptionSerializer registers it alongside without
becoming the default. MessageTypePolicies<T>.Encrypt() and the
.Encrypted() endpoint extension swap envelope.Serializer (not just
ContentType) so wire bytes are actually encrypted across all
transports. Promotes WolverineOptions.TryFindSerializer to public so
the per-type/per-endpoint paths can resolve the encrypting serializer
by content-type.
Adds four acceptance tests in src/Testing/CoreTests/Acceptance/
encryption_acceptance.cs covering the encryption feature end-to-end:
- Local-queue round-trip of an encrypted message through UseEncryption.
- Envelope routing selects EncryptingMessageSerializer.
- Two-host TCP scenarios that drive EncryptionKeyNotFoundException
  and MessageDecryptionException to the dead-letter queue.

The error-policy tests adapt to Wolverine's existing tracking
behaviour: WaitForAnyDeadLetteredEnvelope handles the case where
deserialization fails before envelope.Message is materialized,
and exception types are matched via AllRecordsInOrder().Exception
because TCP receive paths use NullMessageStore and don't stamp
Envelope.Failure.
The sample demonstrates RegisterEncryptionSerializer plus per-type
.Encrypt() — PaymentDetails goes encrypted, OrderShipped stays plain.
The docs page covers configuration, IKeyProvider (with the borrowed-
reference contract), selective encryption, key rotation, the
header-leak caveat, and error handling. The error-handling section
notes that OnException<> retry policies do not apply to encryption
exceptions because Wolverine's HandlerPipeline routes deserialization
failures directly to the dead-letter queue; users should retry
inside their IKeyProvider implementation if they need it.
Introduces internal static EncryptingMessageSerializer.BuildAad that
produces the canonical length-prefixed UTF-8 AAD layout
("wlv-enc-v1" || u16-be(len(MT)) || MT
              || u16-be(len(KeyId)) || KeyId
              || u16-be(len(InnerCT)) || InnerCT)
used to bind MessageType, KeyId, and InnerContentType into the AES-GCM
tag. Future tasks wire this into the encrypt/decrypt paths.

Lengths are validated via GetByteCount before any allocation, so
oversized inputs are rejected without the bytes ever being encoded.
Two tests cover the byte-format spec and null-MessageType normalization
(null and empty produce identical AAD).
…M tag

Wires BuildAad into both encrypt and decrypt paths. WriteAsync calls
BuildAad(envelope.MessageType, keyId, _inner.ContentType) and passes
the AAD into AesGcm.Encrypt. ReadFromDataAsync reads InnerContentType
from the headers, rebuilds AAD from the received MessageType, KeyId,
and InnerContentType, and passes it into AesGcm.Decrypt. Tampering
any of these three fields on the wire causes Decrypt to throw
AuthenticationTagMismatchException, which the existing catch wraps as
MessageDecryptionException and the pipeline routes to DLQ.
Adds a guard in HandlerPipeline.TryDeserializeEnvelope that runs before
serializer selection: if the envelope's MessageType resolves to a type
in WolverineOptions.RequiredEncryptedTypes, OR the envelope's
Destination URI is in RequiredEncryptedListenerUris, AND the
ContentType is not application/wolverine-encrypted+json, the envelope
is routed to the dead-letter queue with EncryptionPolicyViolationException.
No body bytes ever reach a serializer for a forged plaintext envelope.

OR-semantics: either the type marker (from MessageTypePolicies<T>.Encrypt())
or the listener marker (from ListenerConfiguration<>.RequireEncryption())
is sufficient to require encryption.

TCP two-host acceptance tests:
- receive_unencrypted_message_for_required_type_routes_to_error_queue:
  forged plain JSON for a per-type-marked sensitive type → DLQ with
  EncryptionPolicyViolationException; handler never invoked.
- receive_unencrypted_message_on_required_listener_routes_to_error_queue:
  same forge but the marker is on the listener via RequireEncryption();
  exercises the destination-URI branch independently of type marking.
- encrypted_message_for_required_type_round_trips_two_host: negative
  control — both sides marked Encrypt<T>(), full TCP encrypt/decrypt
  round-trip succeeds; proves the guard does not block legitimate
  encrypted traffic.
- plain_message_for_unmarked_type_passes_when_encryption_is_configured:
  rolling-deploy scenario — receiver has UseEncryption() configured
  but the type is unmarked; plain JSON flows through normally with
  no DLQ.

Removes the misleading encrypted_message_round_trips_end_to_end test.
It claimed end-to-end coverage but published through a non-durable
LocalQueue, which passes envelopes in-memory without invoking the
cipher. Real end-to-end coverage now lives in the new TCP round-trip
test above.

The guard sits inside the existing try/catch in TryDeserializeEnvelope
so any future fallibility in the lookup path is wrapped consistently
with other deserialization failures, and Logger.Received(envelope)
fires for rejected envelopes via the existing finally block.
…P/Local

The per-listener encryption guard in HandlerPipeline.RequiresEncryption
keyed off envelope.Destination. That field is populated on the receive
side only by transports that round-trip Wolverine envelope properties
(TCP, Local). Broker transports — RabbitMQ, Kafka, Azure Service Bus —
do not populate it, so RequireEncryption() on those listeners was a
silent no-op: forged plain-JSON envelopes were deserialized normally
instead of being routed to the dead-letter queue.

The guard now reads _endpoint.Uri from the HandlerPipeline's own
listener endpoint (injected via the constructor used by every receive
pipeline construction site). The lookup is transport-agnostic and no
longer depends on a sender-controlled header.

Defense-in-depth side benefit: even on transports that do populate
envelope.Destination, the receiver no longer trusts that header for
the policy decision.

Also corrects the docstring on RequiredEncryptedListenerUris, which
named the wrong populator method.

Adds a regression test (RequiresEncryption_uses_listener_endpoint_uri_
not_envelope_destination) that constructs an envelope with
Destination=null on a local-queue listener marked RequireEncryption()
and asserts the guard still produces EncryptionPolicyViolationException
via the listener endpoint URI. Verified to fail on the prior code
where the lookup keyed off envelope.Destination.
…c honesty

UseSystemTextJsonForSerialization no longer silently disables encryption
when called after UseEncryption. The method now mirrors the existing
UseNewtonsoftForSerialization pattern: if the current default serializer
already wraps the inner JSON serializer (for encryption), the new STJ
serializer is registered alongside but does not replace the default.
Calling UseEncryption and UseSystemTextJsonForSerialization in either
order now produces the same final state.

Endpoint-level .Encrypted() now validates at host startup. The send-side
configuration moved from extension methods on ISubscriberConfiguration<T>
and LocalQueueConfiguration to instance methods on
SubscriberConfiguration<T, TEndpoint> and ListenerConfiguration<TSelf,
TEndpoint>, where the encrypting-serializer lookup runs at endpoint
compile time. A misconfigured endpoint (.Encrypted() called without
prior UseEncryption or RegisterEncryptionSerializer) now fails at
host.StartAsync() with a clear InvalidOperationException, rather than
at first message dispatch. EncryptOutgoingEndpointRule no longer
carries the lazy lookup; it is constructed with the resolved serializer.

Removes two no-op OnException<...>().MoveToErrorQueue() registrations
from the receive-error acceptance tests. Wolverine routes deserialization
failures to the dead-letter queue unconditionally, before user error-
handling policies run, so those registrations had no effect; keeping them
in the tests was misleading.

Corrects the selection-precedence sentence in the encryption guide. The
runtime applies per-type metadata rules after per-endpoint outgoing rules,
so the actual last-write-wins precedence is per-type > endpoint > global
default. For the encryption feature this distinction is moot — both
markers swap to the same encrypting-serializer instance — but stating the
incorrect order would mislead anyone applying the same mental model to
custom envelope rules.
…ive-copy keys

CachingKeyProvider used to start the single-flight inner fetch with the
CancellationToken of whichever caller arrived first. If that caller
cancelled, the shared Task<byte[]> faulted with OperationCanceledException
and every concurrent caller — even those with healthy tokens — saw the
cancellation. The shared inner task now runs with CancellationToken.None;
per-caller cancellation is applied only at the caller's own await via
Task.WaitAsync(token). A cancelled caller stops waiting; the inner KMS
call continues to completion for any remaining waiters and the result is
still cached.

CachingKeyProvider's cache used to grow without bound. Entries were
removed only when an access for the same key-id discovered an expired
TTL, so workloads with many distinct key-ids (per-tenant keys, frequent
rotation, test fixtures) leaked memory. The cache is now bounded by an
LRU policy with a configurable maximum entry count (default 1024,
configurable via a new optional constructor parameter). On insert when
full, the least-recently-used entry is evicted; on access, the entry
moves to the head of the LRU list. Backing store is a small internal
class with a single lock around the dictionary index and the linked-list
ordering.

InMemoryKeyProvider used to alias the caller's byte arrays. Although
the IKeyProvider documentation says callers must not mutate the returned
key bytes, the in-box implementation should not require trust in that
contract for its own state. The constructor now stores a defensive copy
of each caller-supplied key array, so callers can zero or otherwise
mutate their setup arrays without corrupting the provider.
…uard

The send-side EncryptMessageTypeRule<T> encrypts every outbound
message whose runtime type CanBeCastTo<T>() (polymorphic), but the
receive-side guard in HandlerPipeline.RequiresEncryption checked
RequiredEncryptedTypes.Contains(type) for exact membership. When
.Encrypt() was registered on an interface or abstract base type,
only the literal T was added to RequiredEncryptedTypes — so a forged
plaintext envelope whose MessageType named a concrete subtype
slipped past the guard and reached the JSON serializer, defeating
the encryption guarantee.

Adds WolverineOptions.IsEncryptionRequired(Type), which mirrors the
send-side CanBeCastTo<T>() semantics: cache lookup first so previously
computed answers survive any later mutation of RequiredEncryptedTypes,
short-circuit on empty set to avoid unbounded cache growth on
no-encryption hosts, then exact match against the set, then a
polymorphic IsAssignableFrom scan, with the per-type result cached in
a ConcurrentDictionary<Type,bool> for O(1) hot-path lookup.
HandlerPipeline.RequiresEncryption now defers to this method.
…olation

Receive-side audit of the message-encryption feature surfaced three
silent-failure paths in the configuration surface plus weak test
hygiene that would race once xUnit class-parallel tests share a
process.

Production fixes:

- ListenerConfiguration.RequireEncryption() now mirrors the startup-
  time validation that Encrypted() already performs on the sender
  side. Without an encrypting serializer registered, every inbound
  envelope would be dead-lettered with no path to decrypt the
  encrypted ones; fail fast at host build instead.

- WolverineOptions.UseEncryption() and RegisterEncryptionSerializer()
  refuse a second invocation. Repeating the call would wrap an
  already-wrapping serializer, producing envelopes that double-
  encrypt on send but only single-decrypt on receive — silent data
  loss with no error surface.

- EncryptingMessageSerializer wraps wrong-sized keys returned from
  custom IKeyProvider implementations into EncryptionKeyNotFound-
  Exception with the offending key-id and length. Previously a
  16-byte (or any non-32) key produced a raw CryptographicException
  from the AesGcm constructor, thrown outside the WriteAsync /
  ReadFromDataAsync try-catch, and surfaced to user code with no
  encryption context attached.

Test hygiene:

- encryption_acceptance handler-receive lists move from List<T> to
  ConcurrentBag<T>, and per-test isolation is enforced via the test
  class's constructor and IDisposable rather than scattered .Clear()
  calls in some tests. The static lists are a hazard once any class-
  parallel xUnit run shares a process; fix it now.

- Add cross-inner-serializer round-trips (Newtonsoft sender / STJ
  receiver and vice versa) to lock the AAD-binding contract: sender
  inner ContentType travels via the InnerContentTypeHeader, and any
  JSON-flavor inner on the receive side can decrypt and dispatch.
Test-only changes from the encryption-feature audit. No production
behavior change.

- Rename three misleading tests so the name matches what they
  verify; back the two delegation tests with a TrackingInnerSerializer
  that observes the call instead of inferring it from a bubbling
  exception.
- Remove four tautologies that exercised HashSet<T>, a property
  read, or `IsAbstract` reflection rather than encryption logic.
- Extract the receive-side DLQ assertion into
  ShouldHaveDeadLetteredWith<TException>() so a future change to
  which tracking record carries the exception updates one helper,
  not five acceptance tests.
One small production guard plus tests for the remaining edge cases
on the encryption feature.

Production:

- EncryptingMessageSerializer rejects a null/empty DefaultKeyId from
  custom IKeyProvider implementations up front. Previously the value
  reached BuildAad and the provider's lookup, where it surfaced as
  NullReferenceException or an opaque ArgumentNullException with no
  encryption context attached.

Tests added (10 cases across the affected files):

- HandlerPipeline no-endpoint constructor still applies the per-type
  encryption marker; the listener-URI branch correctly short-circuits
  when there is no endpoint to read .Uri from.
- OperationCanceledException from a key provider propagates unchanged
  through both WriteAsync and ReadFromDataAsync — verified to NOT be
  wrapped as MessageEncryptionException.
- Empty envelope.Data on receive produces MessageDecryptionException
  via the length guard rather than a span-slicing crash.
- Negative AAD-binding test: an unmarked type routes through the
  inner serializer with no encryption headers stamped, even when an
  encrypting serializer is registered.
- CachingKeyProvider with maxEntries=1 evicts immediately on a
  distinct second key, repeats are still cache hits.
- TTL-boundary: an entry well within TTL stays cached (catches a
  > vs >= regression in the expiry check).
- Polymorphic IsEncryptionRequired returns true when a type matches
  multiple registered marker interfaces.
- Provider returning null/empty DefaultKeyId fails with a clean
  EncryptionKeyNotFoundException naming the offending provider.
- Receive-side forgery: encrypted content-type plus plain-JSON body
  with plausible headers still fails the auth tag check.
- Body of exactly 28 bytes (the length-guard minimum) reaches
  AesGcm.Decrypt and surfaces as MessageDecryptionException, not a
  raw CryptographicException.
…e contract

Two boundary tests that close gaps the existing suite only covered
indirectly. No production behavior change.

- wire_does_not_contain_plaintext_when_encryption_is_required:
  in-process MITM TCP proxy between sender and receiver captures every
  byte flowing in the sender->receiver direction. Asserts the captured
  buffer contains the encrypted-content-type marker but not the
  plaintext canary, and the receiver still successfully decrypts and
  dispatches. This is the only test that proves the bytes Wolverine
  actually transmits are not the plaintext — every other test inspects
  the serializer's output or relies on a successful round-trip.

- durable_persistence_path_serializes_ciphertext_not_plaintext:
  locks the chain that keeps plaintext off disk in the outbox path —
  EnvelopeRules apply (assigning the encrypting serializer) before
  PersistOrSendAsync, and the persistence layer reads envelope.Data
  which is lazy and triggers Serializer.Write on first access. Both
  materialisation entry points are exercised: the sync Data getter
  (current outbox path) and GetDataAsync (the async-serializer path
  a future migration would use). Out of scope and noted in the test:
  SendRawMessageAsync, which accepts pre-serialized bytes by design.
- WolverineOptions: RequiredEncryptedTypes, RequiredEncryptedListenerUris,
  IsEncryptionRequired are internal — the receive-side guard cannot be
  silently disabled by host code mutating a public collection.
- EncryptingMessageSerializer:
  - WriteMessage(object) and ReadFromData(byte[]) throw InvalidOperationException
    instead of returning the inner serializer's plaintext under the encrypted
    content-type.
  - ReadFromDataAsync rejects envelopes missing the inner-content-type header
    after the tag check (defense-in-depth; legit Wolverine senders always
    write it).
  - BuildAad encodes directly into a single buffer.
  - Class remarks document that key-provider calls use CancellationToken.None
    and that no-envelope overloads now throw.
- Exception hierarchy: KeyId moved off the abstract base onto the two
  subclasses that actually carry one (KeyNotFound, Decryption); policy
  violations no longer carry an empty KeyId.
- InMemoryKeyProvider.GetKeyAsync returns a defensive copy so a misbehaving
  caller cannot corrupt subsequent encryptions.
- Sample (EncryptionDemo): demonstrates RequireEncryption() on a listener,
  routes only sensitive types to the encrypted queue, header comment now
  honestly describes that LocalQueue is in-process pass-through.
- Docs: configuration-order warning corrected — Use*Json* setters preserve
  encryption, so order is no longer load-bearing.
- Tests updated to match new contracts; rolling-deploy test gains a
  clarifying comment about the unmarked listener.
Encryption acceptance and options tests pulled IMessageBus directly from
the root provider with host.Services.GetRequiredService<IMessageBus>().
IMessageBus is registered as scoped, so under Host.CreateDefaultBuilder's
default ValidateScopes=true (active in Development env, e.g. when run
from an IDE) every test that did this threw InvalidOperationException
before reaching the assertion.

Switch all 13 sites to host.MessageBus() — the idiomatic Wolverine helper
that constructs a bus from IWolverineRuntime and bypasses scope resolution,
matching how the rest of the acceptance suite (indefinite_scheduled_retries,
streaming_handler_support, multi_tenancy, …) gets its bus. Sample
EncryptionDemo updated for the same reason. Drop the now-unused
Microsoft.Extensions.DependencyInjection usings.
@jeremydmiller jeremydmiller merged commit 1ca09c5 into JasperFx:main May 1, 2026
20 of 21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: Application-layer message encryption (AES-256-GCM)

2 participants